Skip to content
View Article Network

How to use Vue with ASP.NET Razor

Versions Used

Introduction

For the reasons behind using Vue 2 to replace jQuery, please refer to Discussing jQuery. This article continues to use Vue 2 rather than the latest Vue 3, primarily because we have not yet found a package that can replace VeeValidate 2 while maintaining frontend Model Validation.

Architecture Overview

For Vue syntax tutorials, please refer to the official Vue 2.x Guide. This article will not cover basics, but will focus on the parts relevant to the architecture.

How to Create a Vue Object

Creating a Vue object is generally divided into two parts:

  1. You need a root DOM node to serve as the Vue Template, which contains the content to be rendered. It should provide an attribute that allows Vue to easily find this DOM via a selector (usually an id). If Vue finds multiple DOM elements, only the first one will take effect.
  2. Create a Vue object in JavaScript, passing in the following parameters:
    • el: A selector string used to find the root DOM node, e.g., '#app' to find the DOM with the ID app.
    • data: Usually an object or a function returning an object. The object's properties must include the keys to be used, e.g., { records: [] } or function() { return { records: [] } }.
    • methods: An object containing the methods Vue will use, e.g., { handler: function() { }}.
    • created: A function executed after the Vue object is created; data loading is typically performed here.
    • computed: An object composed of key-function pairs, conceptually similar to C# getters, e.g., { recordCount: function() { return records.length } }.

A simple sample is as follows:

HTML part

html
<div id="app">
  <div>
    Number of records: {{ recordCount }}
  </div>
  <table>
      <tr>
          <th>Title 1</th>
          <th>Title 2</th>
      </tr>
      <tr v-for="record in records">
          <th>{{ record.col1 }}</th>
          <th>{{ record.col2 }}</th>
          <th>
              <button type="button" v-on:click="handler1(record.col1, record.col2)">Button</button>
          </th>
      </tr>
  </table>
  <button type="button" v-on:click="handler2">Clicked {{ count }} times</button>
</div>

JavaScript part

javascript
let app = new Vue({
  el: '#app', // Use a selector to find the DOM to render; here it finds the DOM with id 'app'
   data: {
      count: 0,
      records: []
   },
   methods:{
        handler1: function(arg1, arg2) {
            console.log(arg1 + ' ' + arg2);
        },
        handler2: function(arg1, arg2) {
            this.count += 1;
        }
    },
    created: function() {
        // $el is not yet created, but data is accessible. Ajax page data loading is usually placed here.
        this.records = [
            { col1: 'record1 col1', col2: 'record1 col12' },
            { col1: 'record2 col1', col2: 'record2 col12' }
        ]
    },
    computed: {
        recordCount: function() {
          return this.records.length;
        }
      }
});

Mixin

Excerpt from official documentation:

Mixins are a flexible way to distribute reusable functionalities for Vue components. A mixin object can contain any component options. When a component uses a mixin, all options in the mixin will be "mixed" into the component's own options.

v-cloak

Excerpt from official documentation:

When using templates written directly in the DOM, a situation called "flash of uncompiled templates" may occur: users might see double curly braces before the component is mounted and replaces them with actual rendered content.

v-cloak remains on the bound element until the associated component instance is mounted. Combined with CSS rules like [v-cloak] { display: none }, it can hide the original template until the component is compiled.

Architecture Prototype

Common website content is usually placed in _Layout.cshtml. Knowing about Mixins, we can write the Vue Object creation logic there. Each page only needs to create its own parameter object, and when creating the Vue Object, use the mixins parameter to integrate them. The code looks roughly like this:

html
<div id="vueApp" v-cloak>
    @RenderBody()
</div>

<!-- ... -->

// Declare the mixins variable for use when creating the Vue object later
<script>
    let mixins = [];
</script>
// Each page will create a pageMixin in the Scripts section and add it to mixins
@await RenderSectionAsync("Scripts", required: false)
// Create the Vue object; at this point, mixins already contains pageMixin
<script>
    new Vue({
        el: '#vueApp',
        mixins: mixins
    });
</script>

Complete Code

_Layout.cshtml

html
<!DOCTYPE html>
<html lang="zh-Hant-TW">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="~/css/site.min.css" asp-append-version="true" />
    @RenderSection("Head", required: false)
</head>
<body>
    <div id="vueApp" class="container" v-cloak>
        @RenderBody()
    </div>
    <script src="~/lib/vue/vue.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>
    <script>
        let mixins = [];
    </script>

    @await RenderSectionAsync("Scripts", required: false)

    <script>
        new Vue({
            el: '#vueApp',
            mixins: mixins
        });
    </script>
</body>
</html>

Views/Pages/{Page}.cshtml

csharp
@section Scripts {
    <script>
        mixins.push({
            data: function () {
                return {
                    // Add Vue Data Properties used by the page here
                };
            },
            methods: {
                // Add Vue Methods used by the page here
            },
            created: function() {
                // Implement page data loading here
            }
        });
    </script>
}

site.css

css
[v-cloak] {
    display: none;
}

Considerations for Integrating Vue and ASP.NET Razor

  1. Vue Templates cannot contain script tags. Therefore, in the previous example, pageMixin creation was written inside @section Scripts { } to avoid triggering the following error:

    html
    <div id="app">
        <script></script> <!-- Triggers error -->
    <div>
    <script>
        new Vue({ el: '#app'});
    </script>
    @* Error message
    [Vue warn]: Error compiling template:
    Templates should only be responsible for mapping the state to the UI. Avoid placing tags with side-effects in your templates, such as <script>, as they will not be parsed.
    *@
  2. Some Vue syntax shorthands use @, such as v-on:click which can be shortened to @click. However, this is not recommended in Razor Pages because adding @ inside Tag Helpers may cause compilation failures. Forcing the use of Vue's @ shorthand in Razor Pages leads to a mix of shorthand and non-shorthand syntax. The scenarios where @ is encountered are as follows:

    • @ is also a Razor syntax keyword, so you generally need to add another @ to escape it, e.g., @@click.
    • In Tag Helpers, @ cannot appear outside of attribute values; even adding @ to escape it will cause a compilation error.
    html
    <-- No asp-for used, it is standard HTML, escaping with @ works -->
    <input type="text" @@click="handleClick" />
    <-- Using asp-for, it is defined as a TagHelper, @ causes compilation error -->
    <input type="text" asp-for="Test" @click="handleClick" />
    <-- Using asp-for, escaping with @ still causes compilation error -->
    <input type="text" asp-for="Test" @@click="handleClick" />
    <-- Using asp-for, @ can only appear in the attribute value position -->
    <input type="text" asp-for="Test" test="@Model.Test" />

How to Replace jQuery with Vue

When creating a new ASP.NET Core Web project, there are some features dependent on jQuery. Here are their alternatives:

Bootstrap

Since Bootstrap 5 has dropped the jQuery dependency, you can upgrade directly to version 5. Just note that there are differences in syntax structure between major versions of Bootstrap, so you will need to adjust your HTML accordingly.

Ajax

Vue originally had its own ajax package, but the author stopped maintaining it and recommended using axios instead.

One thing to note: in the original MVC Framework, to handle XSRF/CSRF attacks, you needed to write @Html.AntiForgeryToken() in the form to generate an Antiforgery hidden field and add the [ValidateAntiForgeryToken] attribute to the Controller Action. In ASP.NET Core, both of the following methods automatically add the Antiforgery hidden field. For more details, please refer to Prevent Cross-Site Request Forgery (XSRF/CSRF) attacks in ASP.NET Core.

html
<form method="post">
    <!-- ... -->
</form>

@using (Html.BeginForm("Index", "Home"))
{
    <!-- ... -->
}

Another point to note is that ASP.NET Core MVC still requires adding [ValidateAntiForgeryToken] for XSRF/CSRF protection, but Razor Pages perform this automatically. Therefore, you need to add the following code to axios so that Razor Pages' ajax calls work correctly.

site.js

javascript
// Vue loads VeeValidate
const config = {
    locale: 'zh_TW',
    events: 'change|blur'
};
Vue.use(VeeValidate, config);

// Add RequestVerificationToken to headers for ajax
axios.interceptors.request.use(
    config => {
        let token = document.querySelector('input[name="__RequestVerificationToken"]');
        if (token !== null) {
            config.headers = {
                RequestVerificationToken: token.value
            }
        }
        return config;
    },
    error => {
        return Promise.reject(error);
    }
);

Validation

.NET MVC and Razor Pages have a convenient Model Validation feature. By adding Validation Attributes to the ViewModel, you can achieve simple frontend and backend validation. Frontend validation relies on jQuery Validation. If you don't use jQuery, you have to write the frontend validation yourself, as it cannot rely on Validation Attributes to automatically add validation.

Here, we use VeeValidate 2 to demonstrate an alternative approach. The reason for choosing VeeValidate 2 over VeeValidate 3 is that VeeValidate 3 only supports the Vue Component approach, which requires significant HTML changes, whereas VeeValidate 2 is easier to integrate with existing Tag Helpers. In addition to the old Html Helpers, ASP.NET Core provides Tag Helper syntax. Here, we add two Tag Helpers to generate the HTML Attributes required by VeeValidate.

csharp
[HtmlTargetElement("input", Attributes = ForAttributeName)]
public class VeeValidationInputTagHelper : TagHelper {
    private const string ForAttributeName = "asp-for";
    private const string DataValidationAs = "data-vv-as";
    private const string ValidateAttribute = "v-validate";
    private const string RefAttribute = "ref";
    private const string OtherValidateAttribute = "vee-other-validate";

    [HtmlAttributeName(ForAttributeName)]
    public ModelExpression? For { get; set; }

    [HtmlAttributeName(OtherValidateAttribute)]
    public string? OtherValidate { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output) {
        if (context is null) {
            throw new ArgumentNullException(nameof(context));
        }

        if (output is null) {
            throw new ArgumentNullException(nameof(output));
        }

        if (For is null) {
            return;
        }

        if (!context.AllAttributes.ContainsName(DataValidationAs)) {
            output.Attributes.Add(DataValidationAs, For.Metadata.GetDisplayName());
        }

        if (!context.AllAttributes.ContainsName(RefAttribute)) {
            output.Attributes.Add(RefAttribute, For.Name);
        }

        if (!context.AllAttributes.ContainsName(ValidateAttribute)) {
            string? validateValues = GetValidateValues();
            if (validateValues != null) {
                output.Attributes.Add(ValidateAttribute, GetValidateValues());
            }
        }
    }

    private string? GetValidateValues() {
        List<string> items = new List<string>();

        if (For is not null) {
            foreach (var validationAttribute in For.Metadata.ValidatorMetadata) {
                switch (validationAttribute) {
                    case CompareAttribute attr:
                        // HACK Not sure if it can be captured correctly
                        string[] forNameParts = For.Name.Split('.');
                        forNameParts[^1] = attr.OtherProperty;
                        items.Add($"confirmed:{string.Join(".", forNameParts)}");
                        break;
                    case CreditCardAttribute _:
                        items.Add("credit_card");
                        break;
                    case EmailAddressAttribute _:
                        items.Add("email");
                        break;
                    case FileExtensionsAttribute attr:
                        items.Add($"ext:{attr.Extensions}");
                        break;
                    case StringLengthAttribute attr:
                        if (attr.MaximumLength > 0) {
                            items.Add($"max:{attr.MaximumLength}");
                        }
                        if (attr.MinimumLength > 0) {
                            items.Add($"min:{attr.MinimumLength}");
                        }
                        break;
                    case MaxLengthAttribute attr:
                        if (attr.Length > 0) {
                            items.Add($"max:{attr.Length}");
                        }
                        break;
                    case MinLengthAttribute attr:
                        if (attr.Length > 0) {
                            items.Add($"min:{attr.Length}");
                        }
                        break;
                    case PhoneAttribute attr:
                        // UNDONE Not supported by Vee natively
                        break;
                    case RangeAttribute attr:
                        string key = attr.OperandType == typeof(DateTime)
                            ? "date_between" : "between";
                        items.Add($"{key}:{attr.Minimum},{attr.Maximum}");
                        break;
                    case RegularExpressionAttribute _:
                        // regex only supports object expression, confirmed only supports string expressions
                        // Considering regex escaping issues, we do not validate regex on the frontend
                        break;
                    case RequiredAttribute _:
                        items.Add("required");
                        break;
                    case UrlAttribute _:
                        items.Add("url");
                        break;
                }
            }
        }

        if (!string.IsNullOrWhiteSpace(OtherValidate)) {
            items.AddRange(OtherValidate.Split('|'));
        }

        if (items.Any()) {
            return $"'{string.Join("|", items)}'";
        }

        return null;
    }
}
csharp
[HtmlTargetElement("span", Attributes = ValidationForAttributeName)]
public class VeeValidationMessageTagHelper : TagHelper {
    private const string ValidationForAttributeName = "vee-validation-for";
    private const string VueShow = "v-show";

    [HtmlAttributeName(ValidationForAttributeName)]
    public ModelExpression? For { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output) {
        if (context is null) {
            throw new ArgumentNullException(nameof(context));
        }

        if (output is null) {
            throw new ArgumentNullException(nameof(output));
        }

        if (For is null) {
            return;
        }

        output.Attributes.Add(VueShow, $"errors.has('{For.Name}')");
        output.Content.SetHtmlContent($"{{{{ errors.first('{For.Name}') }}}}");
    }
}

_ViewImports.cshtml needs to add a reference to the custom Tag Helper. Replace {Project Namespace} with the actual project namespace to reference all Tag Helpers under that namespace.

text
@addTagHelper *, {Project Namespace}

In _Layout.cshtml, when creating the Vue Object, add validateBeforeSubmit, and add ModelState error messages to VeeValidate's errors in created.

javascript
 new Vue({
    el: '#vueApp',
    mixins: mixins,
    methods: {
        validateBeforeSubmit: function (event) {
            this.$validator.validateAll().then(result => {
                if (!result) {
                    event.preventDefault();
                }
            });
        }
    },
    created: function() {
        @if (ViewContext.ViewData.ModelState.ErrorCount > 0) {
            foreach (var pair in ViewContext.ViewData.ModelState.Where(x => x.Value.Errors.Any())) {
                <text>
                    this.$validator.errors.add({
                        field: '@pair.Key',
                        msg: '@Html.Raw(pair.Value.Errors.First().ErrorMessage)'
                    });
                </text>
            }

        }
    }
});

Usage of the new Tag Helper in {Page}.cshtml. Replace {Model Property Name} with the actual name of the model property to be captured.

html
<form method="post" role="form" v-on:submit="validateBeforeSubmit">
    <input type="text" asp-for="{Model Property Name}" />
    <span vee-validation-for="{Model Property Name}" class="text-danger"></span>
</form>

References

Placing Vue Object creation in _Layout.cshtml and using Mixins for integration was inspired by the article Using VueJS with ASP.NET Razor Can be Great!. As for using mixins instead of mixinArray, it is a personal naming preference. Declaring mixins in _Layout.cshtml rather than site.js is based on the belief that keeping declaration and usage in the same place eliminates the need for comments to know where it was declared.

Changelog

    • Initial document creation.